⚠️ 学习声明:本文档基于 Claude Code 2.1.88 源码分析整理,仅供个人学习研究使用,不做任何商业用途。
VCR(Video Cassette Recorder)是 CC 的 API 调用录制/回放系统——让测试无需真实调用 Anthropic API,同时保证与真实 API 行为完全一致。
一、核心理念
问题
| 测试场景 |
挑战 |
| 单元测试跑 Agentic Loop |
需要调用 Claude API,慢且昂贵 |
| CI/CD 环境 |
没有 API Key 或配额,无法调用 |
| 测试一致性 |
API 返回随机,测试结果不可复现 |
| 开发迭代速度 |
每次测试等待 API 响应 |
解决方案
VCR 模式:首次运行时”录制”真实 API 响应存为 fixture 文件,后续复用”回放”录制内容——完全跳过网络调用。
VCR_RECORD=1(录制模式) 正常模式(回放) ↓ ↓ 调用真实 Anthropic API 读取 fixture 文件 ↓ ↓ 保存 fixture JSON 返回缓存响应 ↓ ↓ 返回响应 完全跳过网络
|
二、三种 VCR 函数
2.1 withVCR — 批量消息录制/回放
withVCR( messages: Message[], f: () => Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]> ): Promise<(AssistantMessage | StreamEvent | SystemAPIErrorMessage)[]>
|
用途:Agentic Loop 中非流式的完整 API 响应录制。
Fixture 命名策略:
const filename = `fixtures/${messages.map(m => sha1(dehydrateValue(m.content)).slice(0, 6) ).join('-')}.json`
|
2.2 withStreamingVCR — 流式响应录制/回放
async function* withStreamingVCR( messages: Message[], f: () => AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage> ): AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage>
|
用途:流式 API 响应的录制——先消费完整个 generator 缓存,再按顺序回放。
const buffer = [] for await (const message of f()) { buffer.push(message) }
|
2.3 withTokenCountVCR — Token 计数录制/回放
withTokenCountVCR( messages: unknown[], tools: unknown[], f: () => Promise<number | null> ): Promise<number | null>
|
用途:/count_tokens API 调用的录制。
额外脱敏处理:
const dehydrated = dehydrateValue(jsonStringify({messages, tools})) .replaceAll(cwdSlug, '[CWD_SLUG]') .replace(/UUID-pattern/g, '[UUID]') .replace(/timestamp-pattern/g, '[TIMESTAMP]')
|
这样不同机器、不同时间跑出的 hash 相同,fixture 可以在团队间共享。
三、VCR 启动条件
function shouldUseVCR(): boolean { if (process.env.NODE_ENV === 'test') return true if (process.env.USER_TYPE === 'ant' && isEnvTruthy(process.env.FORCE_VCR)) return true return false }
|
四、脱敏(Dehydrate)机制
问题
Fixture 文件的 hash 必须跨机器、跨时间保持一致,但消息中包含大量环境相关信息:
- 工作目录绝对路径(
/home/zhanglin/project)
- 配置目录(
~/.claude)
- UUID(每次生成不同)
- 时间戳
- 动态数字(文件数量、执行时长、成本)
dehydrateValue 脱敏规则
function dehydrateValue(s: string): string { return s .replace(/num_files="\d+"/g, 'num_files="[NUM]"') .replace(/duration_ms="\d+"/g, 'duration_ms="[DURATION]"') .replace(/cost_usd="\d+"/g, 'cost_usd="[COST]"') .replaceAll(configHome, '[CONFIG_HOME]') .replaceAll(cwd, '[CWD]') .replace(/Available commands:.+/, 'Available commands: [COMMANDS]') }
|
Windows 兼容性:
不替换所有斜杠:原注释解释了为什么:
// 不替换所有前斜杠为 path.sep,会损坏 XML-like 标签 // 如 </system-reminder> 会变成 <\system-reminder>
|
hydrateValue 还原规则
function hydrateValue(s: string): string { return s .replaceAll('[NUM]', '1') .replaceAll('[DURATION]', '100') .replaceAll('[CONFIG_HOME]', getClaudeConfigHomeDir()) .replaceAll('[CWD]', getCwd()) }
|
五、UUID 唯一性保障
问题
sessionStorage.ts 使用 UUID 对消息去重。VCR 回放时如果多次 withVCR 调用返回相同 UUID,不同请求的响应会被识别为”重复消息”丢弃。
解决方案
function mapAssistantMessage( message: AssistantMessage, f: (s: unknown) => unknown, index: number, uuid?: UUID ): AssistantMessage { return { uuid: uuid ?? (`UUID-${index}` as unknown as UUID), requestId: 'REQUEST_ID', ... } }
|
六、CI 中的 Fixture 强制检查
if ((env.isCI || process.env.CI) && !isEnvTruthy(process.env.VCR_RECORD)) { throw new Error( `Fixture missing: ${filename}. Re-run tests with VCR_RECORD=1, then commit the result.` ) }
|
工作流:
开发者本地:VCR_RECORD=1 pnpm test ↓ 生成新 fixture 文件 git add fixtures/*.json && git commit ↓ CI 环境:pnpm test(无 VCR_RECORD) ↓ 读取 fixture,不调用 API ↓ 测试通过
|
七、通用 Fixture 管理(withFixture)
async function withFixture<T>( input: unknown, fixtureName: string, f: () => Promise<T> ): Promise<T> { const hash = sha1(jsonStringify(input)).slice(0, 12) const filename = `fixtures/${fixtureName}-${hash}.json` try { return jsonParse(await readFile(filename)) } catch (e) { if (e.code !== 'ENOENT') throw e } if (env.isCI && !VCR_RECORD) throw new Error(...) const result = await f() await writeFile(filename, jsonStringify(result, null, 2)) return result }
|
八、成本追踪集成
function addCachedCostToTotalSessionCost( message: AssistantMessage | StreamEvent ): void { const costUSD = calculateUSDCost(model, usage) addToTotalSessionCost(costUSD, usage, model) }
|
即使是 VCR 回放,fixture 中记录的 model/usage 信息也会被加入总成本,保持 cost tracking 测试的完整性。
九、关键设计决策
| 决策 |
原因 |
| SHA1 内容寻址 |
相同输入永远映射到相同 fixture,无需手动管理 |
| dehydrate/hydrate |
让 fixture 跨机器/时间复用,团队共享 |
| 流式 VCR 先缓冲 |
Generator 无法直接序列化,先收集再存储 |
| randomUUID 在回放时 |
防止 sessionStorage 的 UUID 去重丢消息 |
| CI 强制 fixture 存在 |
防止 CI 静默跳过测试(fixture 缺失应是显式失败) |
| 成本继续追踪 |
保持 cost tracking 系统在测试中的完整可测性 |
十、面试要点
Q:VCR 和 Mock 有什么本质区别?
Mock 替换接口行为(自定义返回),可能与真实 API 出现漂移。VCR 录制真实 API 响应,回放时完全重现真实行为。大型团队曾因 mock 与 prod 漂移,测试全绿但上线崩溃。CC 选择 VCR 是因为”AI 输出有语义”,mock 一个假的 AI 回复没有测试价值。
Q:为什么 Token Count VCR 需要额外的 UUID/时间戳标准化?
每次测试运行,消息里带新 UUID 和当前时间戳,导致 hash 不同,fixture 每次失效。标准化后相同逻辑输入产生相同 hash,团队成员 A 录制的 fixture 可以被成员 B 在 CI 中使用。
十一、录制回放的形式化原理
11.1 灵感来源:Ruby VCR Gem
VCR 的概念源自 Ruby 生态的 VCR gem(Myron Marston, 2011),最初用于录制和回放 HTTP 交互。Claude Code 将其扩展到 Agent 系统中,但 Agent 系统比纯 HTTP 录制复杂得多——因为 Agent Loop 包含交错的 API 调用和工具执行。
11.2 观察点模型(Observation Point Model)
VCR 的核心是一个”在何处插入录制点”的问题。CC 选择了 API 边界 作为观察点:
Agent Loop VCR 观察点 ───────────────────────────────────────────────────── Turn 1: query() → API call ─────────────── [录制点1: 请求+响应] tool_execute(Bash) ─────────────── [录制点2: 工具执行+输出] tool_execute(Read) yield result → API call ────────── [录制点3: 请求+响应] tool_execute(Edit) ... Turn 2: query() → API call ─────────────── [录制点4: 请求+响应] ...
每次 API 调用 = 一个 VCR observation 工具执行可选录制(通过 refreshVcrOnToolExec)
|
关键设计选择:工具执行是可选的录制点。原因是:
- 文件读取(
FileRead)是确定性的(只要文件内容不变),不需要录制
- Shell 命令(
Bash)的输出可能因环境不同而变化,但录制它们会让 fixture 过于庞大
11.3 确定性重放的三个条件
VCR 要保证回放结果与真实调用一致,必须满足三个形式化条件:
条件 1 — 输入确定性(Input Determinism) hash(messages_dehydrated) 在多次运行中保持不变 手段:dehydrateValue() 消除环境相关的动态内容
条件 2 — 输出不变性(Output Immutability) 录制的 API 响应在回放时按顺序、无修改地返回 手段:fixture 文件只读,CI 环境的 VCR_RECORD 默认关闭
条件 3 — 顺序一致性(Order Consistency) Agent Loop 的 API 调用顺序在回放时与录制时相同 手段:fixture 按录制顺序组织,回放时按序消费
|
如果以上任一条件不满足,VCR 回放就会出现漂移——在实际工程中,最常见的漂移来源是:
- 模型升级后 API 返回格式微调(如新增字段)
- 工具执行的非确定输出(如
date 命令)
- Session ID/UUID 泄露到 fixture hash 中
11.4 Fixture 文件结构
// fixtures/test-case-001.jsonl {"type":"api_request","timestamp":"...","request":{"model":"claude-3-5-sonnet","messages":[...],"system":[...]}} {"type":"api_response","timestamp":"...","response":{"id":"msg_xxx","content":[...],"stop_reason":"tool_use","usage":{...}}} {"type":"tool_execution","tool":"FileRead","input":{"file_path":"src/types.ts"},"output":{"content":"..."}} {"type":"api_request","timestamp":"...","request":{...}} {"type":"api_response","timestamp":"...","response":{...}}
|
JSONL 格式的优势:按行追加(无需重写整个文件),顺序消费(O(1) 读取下一个 fixture),人类可读(方便调试)。
十二、快照测试 vs VCR 测试的哲学差异
12.1 两种”录制-比较”范式的本质区别
传统的快照测试(Jest Snapshot Testing)和 VCR 看起来都”录制真实输出然后在 CI 中比较”,但底层哲学完全不同:
| 维度 |
快照测试 (Jest Snapshot) |
VCR 测试 |
| 录制对象 |
组件/函数的输出值 |
API 调用链的行为序列 |
| 比较方式 |
严格匹配(diff) |
顺序消费相同序列 |
| 更新策略 |
--updateSnapshot 覆盖 |
删除 fixture 文件重新录制 |
| 跨环境一致性 |
依赖 serialize() 稳定性 |
依赖 dehydrate() 脱敏 |
| 不匹配含义 |
输出变化 = 需要 Review |
fixture 缺失 = 需要重新录制 |
| 适用范围 |
纯函数/UI 组件 |
有外部依赖的集成流程 |
12.2 为什么快照测试不适合 AI Agent?
快照测试的逻辑: assert(actualOutput === snapshot)
Agent 测试的问题: LLM 对同一输入可能输出不同的有效文本: "我将读取文件并修复这个 bug" vs "让我先读取文件,然后修复 bug" 这两条文本功能等价,但会触发快照 diff 失败
VCR 的逻辑: - 不比较 LLM 文本输出 - 录制 API 边界(请求/响应)和工具调用 - 测试验证"Agent 是否调用了正确的工具"而非"说了什么"
|
这就是 CC 选择 VCR 的根本原因:AI Agent 的正确性不在于输出文本,而在于执行了正确的工具调用序列。
12.3 行为测试 vs 文本测试
VCR 回答的问题(行为测试): ✅ Agent 在收到 "fix the type error" 后是否调用了 FileEdit? ✅ Agent 是否在修复后运行了 type check? ✅ 工具调用顺序是否符合预期?
快照回答的问题(文本测试): ❌ Agent 的第一句话是 "我来帮你修复..." 还是 "让我看看..."? ❌ Agent 的回答是否与上次完全相同?
|
十三、Fixture 归一化算法详解
13.1 三层归一化架构
Layer 1 — 内容脱敏(dehydrateValue) 消除跨环境差异:路径、时间戳、UUID、动态数字
Layer 2 — 结构归一化(normalizeFixture) 消除跨版本差异:token counts、model name alias
Layer 3 — UUID 确定性(deterministic UUID) 录制时使用序号 UUID(UUID-{index}),回放时使用 randomUUID()
|
13.2 dehydrateValue 的正则表达式设计
function dehydrateValue(raw: string): string { let s = raw; s = s.replace(/num_files="\d+"/g, 'num_files="[NUM]"'); s = s.replace(/duration_ms="\d+"/g, 'duration_ms="[DURATION]"'); s = s.replace(/cost_usd="[\d.]+"/g, 'cost_usd="[COST]"'); s = s.replaceAll(configHome, '[CONFIG_HOME]'); s = s.replaceAll(cwd, '[CWD]'); if (s.includes('Available commands:')) { s = s.replace(/Available commands:.+/, 'Available commands: [COMMANDS]'); } if (s.includes('Files modified by user:')) { s = s.replace(/Files modified by user:.+/s, 'Files modified by user: [FILES]'); } return s; }
|
13.3 归一化的设计权衡
| 策略 |
优点 |
缺点 |
CC 的选择 |
| 激进归一化(替换一切变量) |
fixture 复用率高 |
可能掩盖真实差异 |
❌ |
| 保守归一化(只替换已知变量) |
不误判 |
fixture 复用率低 |
❌ |
| 分层归一化(不同 VCR 函数不同规则) |
针对性最好 |
复杂度高 |
✅ |
CC 采用了分层归一化:withVCR 使用基础脱敏,withTokenCountVCR 额外标准化 UUID 和时间戳,withStreamingVCR 不脱敏流式内容本身。
13.4 归一化失败的检测
十四、Agentic 系统中的非确定性与 VCR 策略
14.1 LLM 非确定性的三个来源
1. 采样的随机性(Sampling Randomness) temperature > 0 → 同一 prompt 产生不同的 token 序列 影响:工具调用的选择可能不同
2. 模型更新的漂移(Model Drift) 模型 checkpoint 更新 → 同一 prompt 可能产生不同输出 影响:录制的 fixture 与新模型行为不一致
3. 上下文的敏感性(Context Sensitivity) System Prompt 中微小变化 → 产出完全不同 影响:CC 版本升级后 fixture 可能失效
|
14.2 CC 的应对策略
| 非确定来源 |
策略 |
实现 |
| 采样随机 |
不录制”所有可能”的输出,只录制”代表性”的输出 |
录制一次真实调用 |
| 模型漂移 |
fixture 不预期”通用”,只验证”已知已知” |
模型升级后重新录制 |
| 上下文敏感 |
严格控制 System Prompt 版本 |
Prompt 有版本号,与 fixture 绑定 |
14.3 VCR 的局限性
| 场景 |
VCR 是否适用 |
原因 |
| 确定性工具调用(FileRead) |
✅ |
输出可预测 |
| 单路径 Agent 流程 |
✅ |
录制一条完整路径 |
| 多分支 Agent 流程 |
⚠️ 部分适用 |
需录制多条路径 |
| 非确定性 LLM 输出 |
⚠️ 部分适用 |
需允许输出变化范围 |
| 外部 API 调用 |
❌ 不适用 |
外部响应可能变化 |
| 并发测试 |
❌ 不适用 |
录制时无并发 |
14.4 最佳实践体系
- Golden Tests:核心功能(如文件编辑、命令执行)使用 VCR,确保回归不会破坏基础能力
- Snapshot Tests:非确定性输出使用宽松匹配(如包含关键词即可)
- Integration Tests:端到端场景使用 mock API + VCR 混合
- Fuzzing Tests:随机输入场景验证 Agent 不会崩溃
十五、VCR 与 Property-Based Testing 的协同
15.1 互补关系
VCR 和 Property-Based Testing (PBT) 是测试 Agent 系统的两个互补视角:
VCR(基于范例): "我知道这个具体场景的正确行为是什么" 验证:Agent 在场景 X 下做了 A→B→C 局限:只覆盖了录制过的场景
PBT(基于属性): "对所有输入,Agent 不应崩溃/不应产生不安全输出" 验证:∀ input ∈ domain, property(input) = true 局限:可以告诉你"没问题"但无法告诉你"对不对"
|
15.2 协同使用模式
for (const input of generateTestInputs({ size: 100 })) { const result = await runAgentWithVCR(input); expect(result.crashed).toBe(false); expect(result.duration).toBeLessThan(60000); expect(result.hasUsedForbiddenTool).toBe(false); if (isGoldenInput(input)) { expect(result.toolCalls).toEqual(goldenFixture.toolCalls); } }
const recorded = await vcrRecord(input); const property = extractProperty(recorded);
|
15.3 测试金字塔的 Agent 版本
┌──────────────┐ │ E2E 测试 │ ← 少量:完整 Agent 会话(VCR 回放) │ (罕见路径) │ ├──────────────┤ │ 集成测试 │ ← 中等:Turn 级 VCR(录制单个 API 调用) │ (常见路径) │ ├──────────────┤ │ 单元测试 │ ← 大量:工具/函数单元测试(传统测试) │ (所有分支) │ ├──────────────┤ │ Property测试 │ ← 基础层:PBT 覆盖无限输入空间 │ (安全属性) │ └──────────────┘
|
这种分层让 Agent 测试既有效率(大部分用单元测试),又有语义正确性保证(VCR 验证工具调用序列)。
涉及源文件
src/services/vcr.ts
src/services/vcr.test.ts
src/services/vcrHelpers.ts